AWSTemplateFormatVersion: '2010-09-09'
Description: >
  Large CloudWatch Log Group Review / Retention Remediator.
  Purpose-specific, opt-in remediation module for identifying large CloudWatch log groups
  and optionally applying retention policies where missing. This module does not delete
  log groups or log streams. It is separate from the cost optimization assessment/reporting template.

Parameters:
  ProjectName:
    Type: String
    Default: large-cwlogs-review
    Description: Short base name used for created resources. Keep short to avoid IAM role name length limits.

  TargetRegionsCsv:
    Type: String
    Default: all-enabled
    Description: Comma-separated AWS Regions to scan, or all-enabled to scan all enabled Regions.

  ExcludedRegionsCsv:
    Type: String
    Default: ap-northeast-3
    Description: Comma-separated AWS Regions to exclude from scanning/remediation.

  DryRun:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: When true, only report what would happen. When false, apply eligible retention changes.

  ActionMode:
    Type: String
    Default: review-only
    AllowedValues:
      - review-only
      - set-retention-if-missing
    Description: review-only never changes resources. set-retention-if-missing applies retention only to eligible log groups without retention.

  LargeLogGroupBytes:
    Type: Number
    Default: 1073741824
    Description: Log groups at least this size are considered large. Default is 1 GiB.

  CriticalLogGroupBytes:
    Type: Number
    Default: 53687091200
    Description: Log groups at least this size are considered critical-size. Default is 50 GiB.

  RetentionInDays:
    Type: Number
    Default: 90
    AllowedValues: [1,3,5,7,14,30,60,90,120,150,180,365,400,545,731,1096,1827,2192,2557,2922,3288,3653]
    Description: Retention period to apply when ActionMode is set-retention-if-missing.

  OnlyIfNoRetention:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: When true, only applies retention to log groups with no retention policy. Recommended true.

  RequireApprovalTag:
    Type: String
    Default: 'false'
    AllowedValues: ['true', 'false']
    Description: When true, only applies retention to log groups tagged with ApprovalTagKey=ApprovalTagValue.

  ApprovalTagKey:
    Type: String
    Default: CostOptApproved
    Description: Tag key required when RequireApprovalTag is true.

  ApprovalTagValue:
    Type: String
    Default: 'true'
    Description: Tag value required when RequireApprovalTag is true.

  IgnoreTagKeysCsv:
    Type: String
    Default: CostOptIgnore,DoNotModify,DoNotDelete
    Description: Comma-separated tag keys that prevent retention modification when present, regardless of value.

  IgnoreTagPairsCsv:
    Type: String
    Default: CostOptIgnore=true,DoNotModify=true,DoNotDelete=true
    Description: Comma-separated key=value tag pairs that prevent retention modification when present.

  IncludeLogGroupPrefixesCsv:
    Type: String
    Default: ''
    Description: Optional comma-separated log group name prefixes to include. Leave blank to include all.

  ExcludeLogGroupPrefixesCsv:
    Type: String
    Default: /aws/lambda/cso-
    Description: Optional comma-separated log group name prefixes to exclude.

  MaxLogGroupsPerRun:
    Type: Number
    Default: 100
    Description: Safety limit. 0 means no limit.

  ContinueOnAccessDenied:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Continue scanning other Regions/log groups when blocked by IAM/SCP.

  ScheduleEnabled:
    Type: String
    Default: 'false'
    AllowedValues: ['true', 'false']
    Description: Whether to create an enabled EventBridge schedule.

  ScheduleExpression:
    Type: String
    Default: rate(30 days)
    Description: EventBridge schedule expression used when ScheduleEnabled is true.

Conditions:
  IsScheduleEnabled: !Equals [!Ref ScheduleEnabled, 'true']

Resources:
  RemediatorRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: LargeCloudWatchLogsReviewPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Sid: DiscoverRegions
                Effect: Allow
                Action:
                  - ec2:DescribeRegions
                Resource: '*'
              - Sid: ReadAndTagLogGroups
                Effect: Allow
                Action:
                  - logs:DescribeLogGroups
                  - logs:ListTagsForResource
                Resource: '*'
              - Sid: PutLogRetention
                Effect: Allow
                Action:
                  - logs:PutRetentionPolicy
                Resource: '*'

  RemediatorFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Ref ProjectName
      Description: Purpose-specific Lambda for large CloudWatch log group review and retention remediation.
      Runtime: python3.12
      Handler: index.handler
      Timeout: 900
      MemorySize: 512
      Role: !GetAtt RemediatorRole.Arn
      Environment:
        Variables:
          TARGET_REGIONS_CSV: !Ref TargetRegionsCsv
          EXCLUDED_REGIONS_CSV: !Ref ExcludedRegionsCsv
          DRY_RUN: !Ref DryRun
          ACTION_MODE: !Ref ActionMode
          LARGE_LOG_GROUP_BYTES: !Ref LargeLogGroupBytes
          CRITICAL_LOG_GROUP_BYTES: !Ref CriticalLogGroupBytes
          RETENTION_IN_DAYS: !Ref RetentionInDays
          ONLY_IF_NO_RETENTION: !Ref OnlyIfNoRetention
          REQUIRE_APPROVAL_TAG: !Ref RequireApprovalTag
          APPROVAL_TAG_KEY: !Ref ApprovalTagKey
          APPROVAL_TAG_VALUE: !Ref ApprovalTagValue
          IGNORE_TAG_KEYS_CSV: !Ref IgnoreTagKeysCsv
          IGNORE_TAG_PAIRS_CSV: !Ref IgnoreTagPairsCsv
          INCLUDE_LOG_GROUP_PREFIXES_CSV: !Ref IncludeLogGroupPrefixesCsv
          EXCLUDE_LOG_GROUP_PREFIXES_CSV: !Ref ExcludeLogGroupPrefixesCsv
          MAX_LOG_GROUPS_PER_RUN: !Ref MaxLogGroupsPerRun
          CONTINUE_ON_ACCESS_DENIED: !Ref ContinueOnAccessDenied
      Code:
        ZipFile: |
          import json
          import os
          import traceback
          from datetime import datetime, timezone

          import boto3
          from botocore.exceptions import ClientError

          def utc_now_iso():
              return datetime.now(timezone.utc).isoformat()

          def env_bool(name, default=False):
              value = os.environ.get(name, str(default)).strip().lower()
              return value in ('1', 'true', 'yes', 'y')

          def env_int(name, default=0):
              try:
                  return int(os.environ.get(name, str(default)).strip())
              except Exception:
                  return default

          def split_csv(value):
              if value is None:
                  return []
              return [item.strip() for item in str(value).split(',') if item.strip()]

          def parse_tag_pairs(value):
              pairs = {}
              for item in split_csv(value):
                  if '=' not in item:
                      continue
                  key, val = item.split('=', 1)
                  key = key.strip()
                  val = val.strip()
                  if key:
                      pairs[key] = val
              return pairs

          def get_enabled_regions(excluded_regions):
              requested = os.environ.get('TARGET_REGIONS_CSV', 'all-enabled').strip()
              if requested and requested.lower() != 'all-enabled':
                  regions = split_csv(requested)
              else:
                  ec2 = boto3.client('ec2', region_name=os.environ.get('AWS_REGION', 'us-east-1'))
                  response = ec2.describe_regions(AllRegions=False)
                  regions = sorted([r['RegionName'] for r in response.get('Regions', [])])
              return [r for r in regions if r not in excluded_regions]

          def has_prefix(name, prefixes):
              if not prefixes:
                  return False
              return any(name.startswith(prefix) for prefix in prefixes)

          def included_by_prefix(name, prefixes):
              if not prefixes:
                  return True
              return any(name.startswith(prefix) for prefix in prefixes)

          def tags_for_log_group(logs, arn):
              if not arn:
                  return {}
              # CloudWatch Logs ARNs sometimes include ':*' for log group resources. Remove it for tag lookup.
              tag_arn = arn[:-2] if arn.endswith(':*') else arn
              try:
                  response = logs.list_tags_for_resource(resourceArn=tag_arn)
                  return response.get('tags', {}) or {}
              except ClientError:
                  return {}

          def has_ignore_tag(tag_map, ignore_tag_keys, ignore_tag_pairs):
              for key in ignore_tag_keys:
                  if key in tag_map:
                      return True, 'ignore_tag_key=' + key
              for key, value in ignore_tag_pairs.items():
                  if tag_map.get(key) == value:
                      return True, 'ignore_tag_pair=' + key + '=' + value
              return False, ''

          def has_approval_tag(tag_map, settings):
              if not settings['require_approval_tag']:
                  return True
              return tag_map.get(settings['approval_tag_key']) == settings['approval_tag_value']

          def severity_for_size(bytes_value, settings):
              if bytes_value >= settings['critical_log_group_bytes']:
                  return 'critical'
              if bytes_value >= settings['large_log_group_bytes']:
                  return 'warning'
              return 'normal'

          def scan_region(region, settings):
              logs = boto3.client('logs', region_name=region)
              result = {
                  'region': region,
                  'evaluated': 0,
                  'large_log_groups': 0,
                  'critical_log_groups': 0,
                  'without_retention': 0,
                  'with_retention': 0,
                  'eligible': 0,
                  'would_update': 0,
                  'updated': 0,
                  'skipped_below_threshold': 0,
                  'skipped_include_prefix': 0,
                  'skipped_exclude_prefix': 0,
                  'skipped_existing_retention': 0,
                  'skipped_missing_approval_tag': 0,
                  'skipped_ignore_tag': 0,
                  'access_denied': 0,
                  'failed': 0,
                  'failures': [],
                  'candidates': [],
                  'updates': []
              }

              try:
                  paginator = logs.get_paginator('describe_log_groups')
                  pages = paginator.paginate()
              except ClientError as e:
                  code = e.response.get('Error', {}).get('Code', 'Unknown')
                  message = e.response.get('Error', {}).get('Message', str(e))
                  result['failures'].append({'operation': 'DescribeLogGroups', 'error_code': code, 'error_message': message})
                  if code in ('AccessDeniedException', 'AccessDenied', 'UnauthorizedOperation') or 'explicit deny' in message.lower():
                      result['access_denied'] += 1
                  else:
                      result['failed'] += 1
                  if not settings['continue_on_access_denied']:
                      raise
                  return result

              for page in pages:
                  for group in page.get('logGroups', []):
                      if settings['max_log_groups_per_run'] and (result['would_update'] + result['updated']) >= settings['max_log_groups_per_run']:
                          result['stopped_due_to_max_log_groups_per_run'] = True
                          return result

                      result['evaluated'] += 1
                      name = group.get('logGroupName', '')
                      stored_bytes = int(group.get('storedBytes', 0) or 0)
                      retention = group.get('retentionInDays')
                      arn = group.get('arn')
                      severity = severity_for_size(stored_bytes, settings)

                      if retention:
                          result['with_retention'] += 1
                      else:
                          result['without_retention'] += 1

                      if not included_by_prefix(name, settings['include_log_group_prefixes']):
                          result['skipped_include_prefix'] += 1
                          continue

                      if has_prefix(name, settings['exclude_log_group_prefixes']):
                          result['skipped_exclude_prefix'] += 1
                          continue

                      if stored_bytes < settings['large_log_group_bytes']:
                          result['skipped_below_threshold'] += 1
                          continue

                      result['large_log_groups'] += 1
                      if stored_bytes >= settings['critical_log_group_bytes']:
                          result['critical_log_groups'] += 1

                      item = {
                          'log_group_name': name,
                          'region': region,
                          'stored_bytes': stored_bytes,
                          'stored_gib': round(stored_bytes / 1024 / 1024 / 1024, 3),
                          'retention_in_days': retention,
                          'severity': severity,
                          'arn': arn
                      }

                      tag_map = tags_for_log_group(logs, arn)
                      item['tags'] = tag_map

                      ignore, ignore_reason = has_ignore_tag(tag_map, settings['ignore_tag_keys'], settings['ignore_tag_pairs'])
                      if ignore:
                          result['skipped_ignore_tag'] += 1
                          item['action'] = 'skipped_ignore_tag'
                          item['reason'] = ignore_reason
                          result['candidates'].append(item)
                          continue

                      if settings['only_if_no_retention'] and retention:
                          result['skipped_existing_retention'] += 1
                          item['action'] = 'skipped_existing_retention'
                          result['candidates'].append(item)
                          continue

                      if not has_approval_tag(tag_map, settings):
                          result['skipped_missing_approval_tag'] += 1
                          item['action'] = 'skipped_missing_approval_tag'
                          item['required_tag'] = settings['approval_tag_key'] + '=' + settings['approval_tag_value']
                          result['candidates'].append(item)
                          continue

                      result['eligible'] += 1

                      if settings['action_mode'] == 'review-only':
                          item['action'] = 'review_only_candidate'
                          result['candidates'].append(item)
                          continue

                      item['target_retention_in_days'] = settings['retention_in_days']

                      if settings['dry_run']:
                          result['would_update'] += 1
                          item['action'] = 'would_put_retention_policy'
                          result['candidates'].append(item)
                          continue

                      try:
                          logs.put_retention_policy(
                              logGroupName=name,
                              retentionInDays=settings['retention_in_days']
                          )
                          result['updated'] += 1
                          item['action'] = 'put_retention_policy'
                          result['updates'].append(item)
                      except ClientError as e:
                          code = e.response.get('Error', {}).get('Code', 'Unknown')
                          message = e.response.get('Error', {}).get('Message', str(e))
                          result['failures'].append({'log_group_name': name, 'operation': 'PutRetentionPolicy', 'error_code': code, 'error_message': message})
                          if code in ('AccessDeniedException', 'AccessDenied', 'UnauthorizedOperation') or 'explicit deny' in message.lower():
                              result['access_denied'] += 1
                          else:
                              result['failed'] += 1
                          if not settings['continue_on_access_denied']:
                              raise

              return result

          def handler(event, context):
              settings = {
                  'excluded_regions': split_csv(os.environ.get('EXCLUDED_REGIONS_CSV', '')),
                  'dry_run': env_bool('DRY_RUN', True),
                  'action_mode': os.environ.get('ACTION_MODE', 'review-only'),
                  'large_log_group_bytes': env_int('LARGE_LOG_GROUP_BYTES', 1073741824),
                  'critical_log_group_bytes': env_int('CRITICAL_LOG_GROUP_BYTES', 53687091200),
                  'retention_in_days': env_int('RETENTION_IN_DAYS', 90),
                  'only_if_no_retention': env_bool('ONLY_IF_NO_RETENTION', True),
                  'require_approval_tag': env_bool('REQUIRE_APPROVAL_TAG', False),
                  'approval_tag_key': os.environ.get('APPROVAL_TAG_KEY', 'CostOptApproved'),
                  'approval_tag_value': os.environ.get('APPROVAL_TAG_VALUE', 'true'),
                  'ignore_tag_keys': split_csv(os.environ.get('IGNORE_TAG_KEYS_CSV', '')),
                  'ignore_tag_pairs': parse_tag_pairs(os.environ.get('IGNORE_TAG_PAIRS_CSV', '')),
                  'include_log_group_prefixes': split_csv(os.environ.get('INCLUDE_LOG_GROUP_PREFIXES_CSV', '')),
                  'exclude_log_group_prefixes': split_csv(os.environ.get('EXCLUDE_LOG_GROUP_PREFIXES_CSV', '')),
                  'max_log_groups_per_run': env_int('MAX_LOG_GROUPS_PER_RUN', 100),
                  'continue_on_access_denied': env_bool('CONTINUE_ON_ACCESS_DENIED', True),
              }

              if isinstance(event, dict):
                  if 'dry_run' in event:
                      settings['dry_run'] = bool(event['dry_run'])
                  if 'action_mode' in event:
                      settings['action_mode'] = str(event['action_mode'])
                  if 'large_log_group_bytes' in event:
                      settings['large_log_group_bytes'] = int(event['large_log_group_bytes'])
                  if 'critical_log_group_bytes' in event:
                      settings['critical_log_group_bytes'] = int(event['critical_log_group_bytes'])
                  if 'retention_in_days' in event:
                      settings['retention_in_days'] = int(event['retention_in_days'])
                  if 'only_if_no_retention' in event:
                      settings['only_if_no_retention'] = bool(event['only_if_no_retention'])
                  if 'require_approval_tag' in event:
                      settings['require_approval_tag'] = bool(event['require_approval_tag'])
                  if 'include_log_group_prefixes' in event:
                      settings['include_log_group_prefixes'] = list(event['include_log_group_prefixes'])
                  if 'exclude_log_group_prefixes' in event:
                      settings['exclude_log_group_prefixes'] = list(event['exclude_log_group_prefixes'])
                  if 'max_log_groups_per_run' in event:
                      settings['max_log_groups_per_run'] = int(event['max_log_groups_per_run'])
                  if 'target_regions' in event and event['target_regions']:
                      os.environ['TARGET_REGIONS_CSV'] = ','.join(event['target_regions'])
                  if 'excluded_regions' in event and event['excluded_regions']:
                      settings['excluded_regions'] = list(event['excluded_regions'])

              if settings['action_mode'] not in ('review-only', 'set-retention-if-missing'):
                  settings['action_mode'] = 'review-only'

              summary = {
                  'started_at': utc_now_iso(),
                  'dry_run': settings['dry_run'],
                  'action_mode': settings['action_mode'],
                  'large_log_group_bytes': settings['large_log_group_bytes'],
                  'critical_log_group_bytes': settings['critical_log_group_bytes'],
                  'retention_in_days': settings['retention_in_days'],
                  'only_if_no_retention': settings['only_if_no_retention'],
                  'require_approval_tag': settings['require_approval_tag'],
                  'approval_tag': settings['approval_tag_key'] + '=' + settings['approval_tag_value'],
                  'include_log_group_prefixes': settings['include_log_group_prefixes'],
                  'exclude_log_group_prefixes': settings['exclude_log_group_prefixes'],
                  'ignore_tag_keys': settings['ignore_tag_keys'],
                  'ignore_tag_pairs': settings['ignore_tag_pairs'],
                  'max_log_groups_per_run': settings['max_log_groups_per_run'],
                  'excluded_regions': settings['excluded_regions'],
                  'regions': [],
                  'totals': {
                      'evaluated': 0,
                      'large_log_groups': 0,
                      'critical_log_groups': 0,
                      'without_retention': 0,
                      'with_retention': 0,
                      'eligible': 0,
                      'would_update': 0,
                      'updated': 0,
                      'skipped_below_threshold': 0,
                      'skipped_include_prefix': 0,
                      'skipped_exclude_prefix': 0,
                      'skipped_existing_retention': 0,
                      'skipped_missing_approval_tag': 0,
                      'skipped_ignore_tag': 0,
                      'access_denied': 0,
                      'failed': 0
                  },
                  'region_results': []
              }

              try:
                  regions = get_enabled_regions(settings['excluded_regions'])
                  summary['regions'] = regions
                  for region in regions:
                      try:
                          region_result = scan_region(region, settings)
                      except Exception as e:
                          region_result = {
                              'region': region,
                              'evaluated': 0,
                              'large_log_groups': 0,
                              'critical_log_groups': 0,
                              'without_retention': 0,
                              'with_retention': 0,
                              'eligible': 0,
                              'would_update': 0,
                              'updated': 0,
                              'skipped_below_threshold': 0,
                              'skipped_include_prefix': 0,
                              'skipped_exclude_prefix': 0,
                              'skipped_existing_retention': 0,
                              'skipped_missing_approval_tag': 0,
                              'skipped_ignore_tag': 0,
                              'access_denied': 0,
                              'failed': 1,
                              'failures': [{'error_code': type(e).__name__, 'error_message': str(e), 'trace': traceback.format_exc()}],
                              'candidates': [],
                              'updates': []
                          }
                      summary['region_results'].append(region_result)
                      for key in summary['totals']:
                          summary['totals'][key] += int(region_result.get(key, 0) or 0)
              finally:
                  summary['ended_at'] = utc_now_iso()
                  print(json.dumps(summary, indent=2, default=str))
              return summary

  RemediatorScheduleRule:
    Type: AWS::Events::Rule
    Condition: IsScheduleEnabled
    Properties:
      Name: !Sub '${ProjectName}-schedule'
      Description: Scheduled run for Large CloudWatch Log Group Review Remediator.
      ScheduleExpression: !Ref ScheduleExpression
      State: ENABLED
      Targets:
        - Arn: !GetAtt RemediatorFunction.Arn
          Id: LargeCloudWatchLogsReviewRemediatorTarget
          Input: '{"source":"eventbridge-schedule","dry_run":true,"action_mode":"review-only"}'

  RemediatorSchedulePermission:
    Type: AWS::Lambda::Permission
    Condition: IsScheduleEnabled
    Properties:
      FunctionName: !Ref RemediatorFunction
      Action: lambda:InvokeFunction
      Principal: events.amazonaws.com
      SourceArn: !GetAtt RemediatorScheduleRule.Arn

Outputs:
  RemediatorFunctionName:
    Description: Lambda function name.
    Value: !Ref RemediatorFunction

  RemediatorFunctionArn:
    Description: Lambda function ARN.
    Value: !GetAtt RemediatorFunction.Arn

  DryRunSetting:
    Description: Current DryRun setting.
    Value: !Ref DryRun

  ActionModeSetting:
    Description: Current ActionMode setting.
    Value: !Ref ActionMode

  ManualReviewExample:
    Description: Example manual review invocation payload.
    Value: !Sub 'aws lambda invoke --function-name ${RemediatorFunction} --cli-binary-format raw-in-base64-out --payload ''{"dry_run":true,"action_mode":"review-only"}'' response.json'

  ManualRetentionDryRunExample:
    Description: Example retention remediation dry-run payload.
    Value: !Sub 'aws lambda invoke --function-name ${RemediatorFunction} --cli-binary-format raw-in-base64-out --payload ''{"dry_run":true,"action_mode":"set-retention-if-missing","retention_in_days":90}'' response.json'
